package za.org.jcicada.deviceproxy.impl;

import java.io.IOException;
import java.lang.reflect.Method;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import za.org.jcicada.genss.SystemURN;
import za.org.jcicada.genss.impl.BaseRunner;
import za.org.jcicada.genss.lifecycle.ILifeCycleServer;
import za.org.jcicada.genss.lifecycle.LogLevel;
import za.org.jcicada.genss.messages.Result;
import za.org.jcicada.genss.messages.SuccessCode;
import za.org.jcicada.genss.sensor.ISensor;
import za.org.jcicada.genss.sensor.ISensorType;
import za.org.jcicada.genss.sensor.ISensorValue;
import za.org.jcicada.genss.sensor.SensorStatus;
import za.org.jcicada.genss.sensor.impl.InvalidSensorValue;
import za.org.jcicada.genss.sensor.impl.Sensor;
import za.org.jcicada.genss.sensor.impl.SensorStatusValue;
import za.org.jcicada.genss.sensor.impl.SensorValue;
import za.org.jcicada.genss.sensor.impl.TypeBoolean;
import za.org.jcicada.genss.sensor.impl.TypeDiscrete;
import za.org.jcicada.genss.sensor.impl.TypeFloat;
import za.org.jcicada.genss.sensor.impl.TypeInt;
import za.org.jcicada.genss.sensor.impl.TypeLRU;
import za.org.jcicada.katcp.Client2;
import za.org.jcicada.katcp.Message;

/**
 * katcp client object used to communicate with a particular device. Manages
 * connection to device as well as reflecting device sensors into the monitoring
 * system. Performs regular watchdog pings of the device.
 * 
 * @author rcrida
 */
public class KatcpClient extends Client2 implements ILifeCycleServer.ICallback {

    /** Logger object used for logging messages. */
    private static final Log logger = LogFactory.getLog(KatcpClient.class);

    /**
     * Thread class for KatcpClient Connection Mode state machine
     */
    public class ConnectionRunner extends BaseRunner {

    	public ConnectionRunner() {
    		super("Thread-" + ConnectionRunner.class.getCanonicalName());
    	}
        protected void strategy() throws InterruptedException {
            ConnectionEnum newMode = modeQueue.take();
            logger.trace(String.format("Entering mode %s", newMode));
            ISensorValue value = new SensorValue(newMode.toString());
            logger.info(String.format("Changing mode to %s", newMode.toString()));
            try {
                if (connection != null) {
                    connection.onExit();
                }
                connectionSensor.setValue(value);
                switch (newMode) {
                case DISCONNECTED:
                    connection = disconnected;
                    break;
                case CONNECTED:
                    connection = connected;
                    break;
                }
                connection.onEnter();
            } catch (Exception ex) {
                logger.error(ex);
            }
        }
    }

    /**
     * Interface for callback mechanism for handling registration of sensors.
     */
    public interface ISensorCallback {

        /**
         * Add a sensor representing a device sensor to the system
         * 
         * @param systemURN the device systemURN
         * @param sensor the sensor reflected from the device
         */
        void addSensor(SystemURN systemURN, ISensor sensor);

        /**
         * Remove a sensor from the system, perhaps when connection is lost
         * 
         * @param sensor the sensor reflected from the device that is being
         *            removed
         */
        void removeSensor(ISensor sensor);
    }

    /** Handle to callback object for sensor registration */
    private ISensorCallback sensorCallback;

    /** The device system URN */
    private SystemURN systemURN;

    /** Configuration built up from details passed in URI to the constructor */
    private KatcpClientDynamicConfiguration configuration;

    /** Thread for mode state machine. */
    private ConnectionRunner modeThread;

    /** Blocking queue for Connection state machine. */
    private BlockingQueue<ConnectionEnum> modeQueue = new LinkedBlockingQueue<ConnectionEnum>();

    /** Sensor representing the current state of the subsystem. */
    protected Sensor connectionSensor = new Sensor(TypeDiscrete.getInstance(ConnectionEnum.stringArray()), "connection", "KatcpClient connection sensor", "");

    /**
     * Sensor representing whether watchdog response is being received
     * timeously.
     */
    private Sensor watchdogSensor = new Sensor(TypeDiscrete.getInstance(WatchdogEnum.stringArray()), "watchdog", "Watchdog response", "");

    public static final long CONNECTION_RETRY_PERIOD = 1000L;

    public static final long WATCHDOG_PING_PERIOD = 5000L;

    private RequestHandler requestHandler = new RequestHandler(this);

    /** Used to count sensors as type information is received */
    private int sensorCount;

    /** Reflected device sensors keyed by name */
    private Map<String, Sensor> deviceSensors = new HashMap<String, Sensor>();

    /**
     * Interface definition for state pattern. Used to define states for the
     * Connection state machine which monitors the state of a connection to a
     * device.
     */
    interface Connection {

        /**
         * Action to take on entering state
         * 
         * @param context the context of the state machine
         */
        void onEnter() throws Exception;

        /**
         * Action to take on existing the state
         * 
         * @param context the context of the state machine
         */
        void onExit() throws Exception;

        /**
         * Action to take upon notification of disconnect from server.
         * 
         * @param reason string describing reason for disconnection
         */
        void onDisconnect(String reason);

        /**
         * Try to shut down the state machine
         * 
         * @param behaviour halt or restart
         * @return result object indicating success of request
         */
        Result shutdown(ILifeCycleServer.Shutdown behaviour);

        /**
         * Set the log level for the specified system
         * 
         * @param level the desired log level
         * @return object describing the result of the request
         */
        Result setLogLevel(LogLevel level);

        /**
         * Request that the specified system be reconnected
         * 
         * @return object describing the result of the request
         */
        Result reconnect();
    }

    /**
     * Base state implementation with default behaviour.
     */
    class ConnectionBase implements Connection {

        public void onEnter() throws Exception {
        }

        public void onExit() throws Exception {
        }

        public void onDisconnect(String reason) {
            logger.info(reason);
            setConnection(ConnectionEnum.DISCONNECTED);
        }

        public Result reconnect() {
            close();
            onDisconnect("Reconnection request");
            return new Result();
        }

        public Result setLogLevel(LogLevel level) {
            return requestHandler.send(new Message(Message.Type.REQUEST, "log-level", new String[] { level.toString() }));
        }

        public Result shutdown(ILifeCycleServer.Shutdown behaviour) {
            return requestHandler.send(new Message(Message.Type.REQUEST, behaviour.toString().toLowerCase(), new String[0]));
        }

    }

    /**
     * Disconnected state implementation. Regularly attempts to connect to the
     * device until successful.
     */
    class Disconnected extends ConnectionBase {

        /** Timer used for retrying connections */
        private Timer connectTimer;

        @Override
        public void onEnter() throws Exception {
            // start timer trying to connect
            connectTimer = new Timer("Timer-" + getClass().getCanonicalName());
            connectTimer.schedule(new TimerTask() {

                /**
                 * Attempt to connect to the server. Upon success then change
                 * state to CONNECTED and stop retrying. If unsuccessful the log
                 * a message and retry.
                 */
                @Override
                public void run() {
                    try {
                        connect();
                        logger.info("Successful connection to " + KatcpClient.this.sc);
                        setConnection(ConnectionEnum.CONNECTED);
                        connectTimer.cancel();
                    } catch (IOException ex) {
                        logger.warn(String.format("Unsuccessful connection to %s:%d", KatcpClient.this.host, KatcpClient.this.port));
                    }
                }

            }, 0L, CONNECTION_RETRY_PERIOD);
        }

        @Override
        public Result reconnect() {
            return new Result(SuccessCode.COMPLETE_FAILURE, "Can't reconnect when not connected");
        }

        @Override
        public Result setLogLevel(LogLevel level) {
            return new Result(SuccessCode.COMPLETE_FAILURE, "Can't set log level when not connected");
        }

        @Override
        public Result shutdown(ILifeCycleServer.Shutdown behaviour) {
            return new Result(SuccessCode.COMPLETE_FAILURE, "Can't shutdown when not connected");
        }

    }

    /**
     * Connected state implementation. Performs initial handshake to set log
     * level and get device sensors then starts periodically pinging watchdog.
     */
    class Connected extends ConnectionBase {

        private Timer watchdogTimer;

        @Override
        public void onEnter() throws Exception {
            setWatchdogState(WatchdogEnum.NOMINAL);
            sensorCallback.addSensor(systemURN, watchdogSensor);
            // TODO check protocol

            // configure log level
            requestHandler.send(new Message(Message.Type.REQUEST, "log-level", new String[] { configuration.logLevel }));

            // start process to mirror sensors
            sensorCount = 0;
            requestHandler.send(new Message(Message.Type.REQUEST, "sensor-list", new String[0]));

            // start watchdog timer
            watchdogTimer = new Timer();
            watchdogTimer.schedule(new TimerTask() {

                /**
                 * Send a watchdog ping request to the server and register a
                 * timeout handler that changes the watchdog sensor state upon
                 * timeout.
                 */
                @Override
                public void run() {
                    requestHandler.send(new Message(Message.Type.REQUEST, "watchdog", new String[] {}), new TimerTask() {

                        @Override
                        public void run() {
                            try {
                                setWatchdogState(WatchdogEnum.TIMEOUT);
                            } catch (InvalidSensorValue ex) {
                                // no action
                            }
                        }

                    });
                }
            }, WATCHDOG_PING_PERIOD, WATCHDOG_PING_PERIOD);
        }

        @Override
        public void onExit() throws Exception {
            watchdogTimer.cancel();
            sensorCallback.removeSensor(watchdogSensor);
            // deregister all the device sensors
            for (Sensor sensor : deviceSensors.values()) {
                sensorCallback.removeSensor(sensor);
            }
            deviceSensors.clear();
        }
    }

    /** The current state of the Connection state machine */
    private Connection connection;

    protected Connection disconnected = new Disconnected();
    protected Connection connected = new Connected();

    /**
     * Constructor
     * 
     * @param sensorCallback handle to object used for sensor registration
     * @param deviceURI the URI for the device including properties in the query
     *            section
     */
    public KatcpClient(ISensorCallback sensorCallback, URI deviceURI) throws URISyntaxException {
        super(deviceURI.getHost(), deviceURI.getPort());
        this.sensorCallback = sensorCallback;
        // urn starts after / at beginning of path
        this.systemURN = new SystemURN(deviceURI.getPath().substring(1));
        configuration = new KatcpClientDynamicConfiguration();
        configuration.loadProperties(createPropertiesFromQuery(deviceURI.getQuery()));
    }

    /**
     * Initialize the connection state machine and make the connection state
     * sensor available.
     */
    public void initialize() {
        // start the threads for the state machines
        modeQueue.clear();
        modeThread = new ConnectionRunner();
        modeThread.start();

        sensorCallback.addSensor(systemURN, connectionSensor);

        // and then set the first mode which will itself set the state
        if (systemURN != null) {
            setConnection(ConnectionEnum.DISCONNECTED);
        }
    }

    /**
     * Stop the connection state machine and remove the connection sensor.
     */
    public void shutdown() {
        // now stop the mode thread if necessary
        if (modeThread != null) {
            modeThread.terminate(BaseRunner.JOIN_TIMEOUT);
            modeThread = null;
        }

        sensorCallback.removeSensor(connectionSensor);
        requestHandler.shutdown();
    }

    public SystemURN getSystemURN() {
        return systemURN;
    }

    /**
     * Forward a reconnect request to the current connection state.
     */
    public Result reconnect() {
        return connection.reconnect();
    }

    /**
     * Forward a log level request to the current connection state.
     */
    public Result setLogLevel(LogLevel level) {
        return connection.setLogLevel(level);
    }

    /**
     * Forward a shutdown request to the current connection state.
     */
    public Result shutdown(ILifeCycleServer.Shutdown behaviour) {
        return connection.shutdown(behaviour);
    }
    
    /**
     * Forward a ping request to the current connection state.
     */
    public Result ping() {
    	return new Result(SuccessCode.COMPLETE_FAILURE, "Not yet implemented");
    }

    /* *************************************************************************
     * katcp message handlers
     **************************************************************************/

    public void informVersion(Message msg) {
        // TODO could fail on protocol name mismatch and just warn on number
        // difference
        String version = msg.getArguments()[0];
        if (!configuration.version.equals(version)) {
            logger.warn(String.format("Device version (%s) does not match expected (%s)", configuration.version, version));
        }
    }

    public void informBuildState(Message msg) {
        logger.info(msg);
    }

    /**
     * Handle device log messages by forwarding them to an appropriate system
     * logger
     * 
     * @param msg level time_ms name message
     */
    public void informLog(Message msg) {
        String[] args = msg.getArguments();
        String level = args[0];
        Date date = new Date(Long.parseLong(args[1]));
        String name = args[2];
        String message = args[3];
        Log deviceLogger = LogFactory.getLog(String.format("%s/%s", systemURN.toString(), name));
        String fullMessage = String.format("%s - %s", date.toString(), message);
        Class<?>[] params = { Object.class };
        try {
            Method m = deviceLogger.getClass().getMethod(level, params);
            m.invoke(deviceLogger, fullMessage);
        } catch (Exception ex) {
            logger.error(String.format("Can't log device message for %s: %s", level, fullMessage));
        }
    }

    public void informSensorList(Message msg) {
        sensorCount++;
        String[] args = msg.getArguments();
        String[] typeArgs = new String[args.length - 4];
        if (typeArgs.length > 0) {
            System.arraycopy(args, 4, typeArgs, 0, typeArgs.length);
        }
        ISensorType type;
        String name = args[0];
        if (args[1].equals("boolean")) {
            type = TypeBoolean.getInstance(typeArgs);
        } else if (args[1].equals("discrete")) {
            type = TypeDiscrete.getInstance(typeArgs);
        } else if (args[1].equals("float")) {
            type = TypeFloat.getInstance(typeArgs);
        } else if (args[1].equals("int")) {
            type = TypeInt.getInstance(typeArgs);
        } else if (args[1].equals("lru")) {
            type = TypeLRU.getInstance(typeArgs);
        } else {
            logger.error(String.format("Unrecognized sensor type: %s", args[1]));
            return;
        }
        String description = args[2];
        String units = args[3];
        Sensor sensor = new Sensor(type, name, description, units);
        deviceSensors.put(sensor.getName(), sensor);
        sensorCallback.addSensor(systemURN, sensor);
        // set up sampling strategy
        requestHandler.send(new Message(Message.Type.REQUEST, "sensor-sampling", new String[] { name, "auto" }));
    }

    public void informSensorStatus(Message msg) throws InvalidSensorValue {
        logger.info(msg);
        String[] args = msg.getArguments();
        long time = Long.parseLong(args[0]);
        int count = Integer.parseInt(args[1]);
        int start = 2;
        for (int i = 0; i < count; i++) {
            String name = args[start + 0];
            Sensor sensor = deviceSensors.get(name);
            SensorStatus status = SensorStatus.valueOf(args[start + 1].toUpperCase());
            Object value = sensor.getType().valueOf(args[start + 2]);
            sensor.setValue(new SensorValue(time, value));
            sensor.setStatusValue(new SensorStatusValue(status, time));
        }
    }

    public void replyHalt(Message msg) {
        requestHandler.receive(msg);
    }

    public void replyLogLevel(Message msg) {
        requestHandler.receive(msg);
    }

    public void replyRestart(Message msg) {
        requestHandler.receive(msg);
    }

    public void replySensorList(Message msg) {
        requestHandler.receive(msg);
        int replyCount = Integer.parseInt(msg.getArguments()[1]);
        if (sensorCount != replyCount) {
            logger.error(String.format("Sensor list reply report %d sensors but received informs for %d", replyCount, sensorCount));
        }
    }

    public void replySensorSampling(Message msg) {
        requestHandler.receive(msg);
        logger.info(msg);
    }

    public void replyWatchdog(Message msg) throws InvalidSensorValue {
        requestHandler.receive(msg);
        setWatchdogState(WatchdogEnum.NOMINAL);
    }

    /* *************************************************************************
     * Protected API
     **************************************************************************/

    /**
     * Sets the mode by adding it to the mode queue.
     * 
     * @param mode the desired mode
     */
    protected void setConnection(ConnectionEnum mode) {
        modeQueue.add(mode);
    }

    /**
     * Sets the watchdog state.
     * 
     * @param state the desired state
     */
    protected void setWatchdogState(WatchdogEnum state) throws InvalidSensorValue {
        if (watchdogSensor.getValue() != null && watchdogSensor.getValue().getValue() != null && !watchdogSensor.getValue().getValue().equals(state)) {
            ISensorValue value = new SensorValue(state.toString());
            logger.info(String.format("Changing watchdog state to %s with status %s", state.toString(), state.getStatus().toString()));
            watchdogSensor.setValue(value);
        }
    }

    /**
     * Called by the base katcp client class on detection that the connection to
     * the device has closed.
     * 
     * @param reason string describing the reason for the disconnection
     */
    @Override
    protected void onDisconnect(String reason) {
        connection.onDisconnect(reason);
    }

    /* *************************************************************************
     * Private API
     **************************************************************************/

    /**
     * Create a properties object from a string containing a comma separated
     * list of property pairs each separated by a =.
     * 
     * @param query string containing properties, eg "key1=value1,key2=value2"
     * @return the properties object
     */
    private Properties createPropertiesFromQuery(String query) {
        Properties properties = new Properties();
        for (String pair : query.split(",")) {
            String[] array = pair.split("=");
            properties.put(array[0], array[1]);
        }
        return properties;
    }

}
